]> dgit.raspbian.org Git - gnome-shell.git/commitdiff
breakManager: Add new state machine for screen time/health breaks
authorPhilip Withnall <pwithnall@gnome.org>
Mon, 4 Mar 2024 16:13:53 +0000 (16:13 +0000)
committerPhilip Withnall <pwithnall@gnome.org>
Thu, 28 Nov 2024 10:44:10 +0000 (10:44 +0000)
This implements health break reminder support in gnome-shell. It depends
on a
few bits and bobs from other modules:
 - New settings schemas in gsettings-desktop-schemas (released in
   47.beta, which Mutter already depends on)
 - A settings UI in gnome-control-center
 - User documentation in gnome-user-docs

It implements the design from
https://gitlab.gnome.org/Teams/Design/settings-mockups/-/blob/master/wellbeing/wellbeing.png.

The core of the implementation is `BreakManager`, which is a state
machine which uses the Mutter `IdleMonitor` to track whether the user
is, or should be, in a screen time break.

The `BreakDispatcher` is based on top of this, and controls showing
notifications, countdown timers, screen fades, the lock shield, etc. to
make the user aware of upcoming or due breaks, as per their notification
preferences.

Unit tests are included to check that `BreakManager` works. These
provide mock implementations of basic GLib clock functions, the
`IdleMonitor` and `Gio.Settings` in order to test the state machine in
faster-than-real-time.

Signed-off-by: Philip Withnall <pwithnall@gnome.org>
See: https://gitlab.gnome.org/Teams/Design/initiatives/-/issues/130
Part-of: <https://gitlab.gnome.org/GNOME/gnome-shell/-/merge_requests/3251>

14 files changed:
data/dbus-interfaces/meson.build
data/dbus-interfaces/org.gnome.Shell.ScreenTime.xml [new file with mode: 0644]
data/gnome-shell-dbus-interfaces.gresource.xml
data/gnome-shell-icons.gresource.xml
data/icons/scalable/status/wellbeing-symbolic.svg [new file with mode: 0644]
data/theme/gnome-shell-sass/widgets/_osd.scss
js/js-resources.gresource.xml
js/misc/breakManager.js [new file with mode: 0644]
js/ui/main.js
js/ui/shellDBus.js
meson.build
po/POTFILES.in
tests/meson.build
tests/unit/breakManager.js [new file with mode: 0644]

index c96bbbb4daa70d71042c086d083bf7394b4c0fec..81281b64d57b92338a1bd1bbd8ef9faa38f613e7 100644 (file)
@@ -4,6 +4,7 @@ dbus_interfaces = [
   'org.gnome.Shell.PadOsd.xml',
   'org.gnome.Shell.Screencast.xml',
   'org.gnome.Shell.Screenshot.xml',
+  'org.gnome.Shell.ScreenTime.xml',
   'org.gnome.ShellSearchProvider.xml',
   'org.gnome.ShellSearchProvider2.xml'
 ]
diff --git a/data/dbus-interfaces/org.gnome.Shell.ScreenTime.xml b/data/dbus-interfaces/org.gnome.Shell.ScreenTime.xml
new file mode 100644 (file)
index 0000000..4749c85
--- /dev/null
@@ -0,0 +1,38 @@
+<!DOCTYPE node PUBLIC
+'-//freedesktop//DTD D-BUS Object Introspection 1.0//EN'
+'http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd'>
+<!--
+  SPDX-License-Identifier: GPL-3.0-or-later
+  SPDX-FileCopyrightText: 2024 GNOME Foundation, Inc.
+-->
+<node>
+
+  <!--
+      org.gnome.Shell.ScreenTime:
+      @short_description: Screen Time interface
+
+      The interface used to access Screen Time and Break Reminders usage data.
+  -->
+  <interface name="org.gnome.Shell.ScreenTime">
+
+    <!--
+        State:
+
+        The state of the break manager.
+         - 0: Break reminders are disabled.
+         - 1: Break reminders are enabled, user is active, no break is needed yet.
+         - 2: A break is needed and the user is taking it.
+         - 3: A break is needed but the user is still active.
+    -->
+    <property name="State" type="u" access="read"/>
+
+    <!--
+        LastBreakEndTime:
+
+        The time (in wall clock seconds since the Unix epoch) when the most
+        recent break ended. If there have been no breaks so far, zero is
+        returned.
+    -->
+    <property name="LastBreakEndTime" type="t" access="read"/>
+  </interface>
+</node>
index c3954ba397f2c3390219d0d1b470a5d4598779e7..8eccc82bf4c97b75d711ac0451994a00538bb127 100644 (file)
@@ -51,6 +51,7 @@
     <file preprocess="xml-stripblanks">org.gnome.Shell.PortalHelper.xml</file>
     <file preprocess="xml-stripblanks">org.gnome.Shell.Screencast.xml</file>
     <file preprocess="xml-stripblanks">org.gnome.Shell.Screenshot.xml</file>
+    <file preprocess="xml-stripblanks">org.gnome.Shell.ScreenTime.xml</file>
     <file preprocess="xml-stripblanks">org.gnome.Shell.Wacom.PadOsd.xml</file>
     <file preprocess="xml-stripblanks">org.gnome.Shell.WeatherIntegration.xml</file>
     <file preprocess="xml-stripblanks">org.gnome.Shell.xml</file>
index f15d0c0e8151f8c30bcf6aeffa4a3f3d3969afbc..a642340922b9c2255f5ca08c15d95522920e0028 100644 (file)
@@ -53,6 +53,7 @@
     <file preprocess="xml-stripblanks">scalable/status/screen-privacy-symbolic.svg</file>
     <file preprocess="xml-stripblanks">scalable/status/switch-on-symbolic.svg</file>
     <file preprocess="xml-stripblanks">scalable/status/switch-off-symbolic.svg</file>
+    <file preprocess="xml-stripblanks">scalable/status/wellbeing-symbolic.svg</file>
   </gresource>
 </gresources>
 
diff --git a/data/icons/scalable/status/wellbeing-symbolic.svg b/data/icons/scalable/status/wellbeing-symbolic.svg
new file mode 100644 (file)
index 0000000..569f278
--- /dev/null
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  SPDX-License-Identifier: CC0-1.0
+  SPDX-FileCopyrightText: 2024 Jakub Steiner
+-->
+<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
+    <g fill="#222222">
+        <path d="m 3.3125 13.683594 c 0.011719 0.007812 0.023438 0.015625 0.039062 0.019531 l 4.195313 2.179687 c 0.289063 0.152344 0.636719 0.152344 0.925781 0 l 4.152344 -2.179687 c 0.019531 -0.007813 0.039062 -0.019531 0.058594 -0.03125 c 3.988281 -2.449219 2.25 -7.671875 -1.703125 -7.671875 c -1.011719 0 -1.75 0.441406 -2.238281 1.128906 c -0.894532 1.257813 -0.691407 1.277344 -1.621094 0.042969 c -0.554688 -0.730469 -1.320313 -1.203125 -2.128906 -1.171875 c -3.996094 0 -5.652344 5.390625 -1.679688 7.683594 z m 7.667969 -5.6875 c 1.726562 0 3.132812 2.449218 0.65625 3.96875 l 0.058593 -0.035156 l -4.152343 2.179687 h 0.925781 l -4.195312 -2.179687 l 0.039062 0.023437 c -2.386719 -1.378906 -1.097656 -3.957031 0.679688 -3.957031 c 1.378906 0 2.039062 0.945312 2.320312 1.535156 c 0.117188 0.25 0.398438 0.46875 0.675781 0.46875 h 0.011719 c 0.277344 0 0.558594 -0.21875 0.675781 -0.46875 c 0.28125 -0.589844 0.945313 -1.535156 2.304688 -1.535156 z m 0 0"/>
+        <path d="m 8 4 c -0.546875 0 -1 -0.453125 -1 -1 s 0.453125 -1 1 -1 s 1 0.453125 1 1 s -0.453125 1 -1 1 z m 0 -4 c -1.660156 0 -3 1.339844 -3 3 s 1.339844 3 3 3 s 3 -1.339844 3 -3 s -1.339844 -3 -3 -3 z m 0 0"/>
+    </g>
+</svg>
index 13a77bca316eda78b8eb604532270534e05f2092..16b3bbc618046d979a3ad7a3a392a42a648740cc 100644 (file)
@@ -35,7 +35,7 @@ $osd_levelbar_height:6px;
 }
 
 // Monitor number label
-.osd-monitor-label {
+.osd-monitor-label, .osd-break-countdown-label {
   background-color: -st-accent-color;
   color: -st-accent-fg-color;
   border-radius: $modal_radius;
index fda48d3989004452d7cb3a43036a057c110d5ca5..898c61177ccb1df202b0c5e23dbcb8e62a7e455b 100644 (file)
@@ -15,6 +15,7 @@
     <file>extensions/sharedInternals.js</file>
 
     <file>misc/animationUtils.js</file>
+    <file>misc/breakManager.js</file>
     <file>misc/config.js</file>
     <file>misc/dateUtils.js</file>
     <file>misc/dbusErrors.js</file>
diff --git a/js/misc/breakManager.js b/js/misc/breakManager.js
new file mode 100644 (file)
index 0000000..748a862
--- /dev/null
@@ -0,0 +1,1439 @@
+// Copyright 2024 GNOME Foundation, Inc.
+//
+// This is a GNOME Shell component to support break reminders and screen time
+// statistics.
+//
+// Licensed under the GNU General Public License Version 2
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License
+// as published by the Free Software Foundation; either version 2
+// of the License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program; if not, write to the Free Software
+// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+//
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+import Clutter from 'gi://Clutter';
+import Gio from 'gi://Gio';
+import GLib from 'gi://GLib';
+import GObject from 'gi://GObject';
+import Meta from 'gi://Meta';
+import Shell from 'gi://Shell';
+import St from 'gi://St';
+
+import * as Gettext from 'gettext';
+import * as Lightbox from '../ui/lightbox.js';
+import * as Main from '../ui/main.js';
+import * as MessageTray from '../ui/messageTray.js';
+import * as SystemActions from './systemActions.js';
+
+export const MIN_BREAK_LENGTH_SECONDS = 10;
+
+const BREAK_OVERDUE_TIME_SECONDS = 60;  // time after a break is due when the user is notified it’s overdue
+const BREAK_UPCOMING_NOTIFICATION_TIME_SECONDS = [2 * 60];  // notify the user 2min before a break is due; this must be kept in descending order
+const BREAK_COUNTDOWN_TIME_SECONDS = 60;
+
+const LIGHTBOX_FADE_TIME_SECONDS = 3;
+const LIGHTBOX_FADE_FACTOR = 0.6;
+
+/** @enum {number} */
+const IdleState = {
+    /* session is active */
+    ACTIVE: 1,
+    /* session is idle */
+    IDLE: 2,
+};
+
+/** @enum {number} */
+/* This enum is exposed over D-Bus (/org/gnome/Shell/ScreenTime) so is
+ * effectively public API */
+export const BreakState = {
+    /* break reminders are disabled */
+    DISABLED: 0,
+    /* break reminders are enabled, user is active, no break is needed yet */
+    ACTIVE: 1,
+    /* user is idle, but no break is needed */
+    IDLE: 2,
+    /* a break is needed and the user is taking it */
+    IN_BREAK: 3,
+    /* a break is needed but the user is still active */
+    BREAK_DUE: 4,
+};
+
+/* The code supports whatever break types the user wants. However, they each
+ * need to be backed by a org.gnome.desktop.break-reminders.* child schema, and
+ * those have to be pre-defined because they all have different default values.
+ * So we need to validate the break types to prevent GSettings complaining about
+ * non-installed schemas.
+ *
+ * So this needs to mirror the child schemas defined in
+ * `schemas/org.gnome.desktop.break-reminders.gschema.xml.in` in
+ * gsettings-desktop-schemas. */
+const SUPPORTED_BREAK_TYPES = [
+    'eyesight',
+    'movement',
+];
+
+/**
+ * Return a string form of `breakState`.
+ *
+ * @param {int} breakState The break state.
+ * @returns {string} A string form of `breakState`.
+ */
+export function breakStateToString(breakState) {
+    return Object.keys(BreakState).find(k => BreakState[k] === breakState);
+}
+
+/**
+ * A manager class which tracks total active/inactive time and signals when the
+ * user needs to take a break (according to their break reminder preferences).
+ */
+export const BreakManager = GObject.registerClass({
+    Properties: {
+        'state': GObject.ParamSpec.int(
+            'state', null, null,
+            GObject.ParamFlags.READABLE,
+            BreakState.DISABLED, BreakState.BREAK_DUE, BreakState.DISABLED),
+        'last-break-end-time': GObject.ParamSpec.uint64(
+            'last-break-end-time', null, null,
+            GObject.ParamFlags.READABLE,
+            0, GLib.MAX_UINT64, 0),
+        'next-break-due-time': GObject.ParamSpec.uint64(
+            'next-break-due-time', null, null,
+            GObject.ParamFlags.READABLE,
+            0, GLib.MAX_UINT64, 0),
+    },
+    Signals: {
+        'break-due': {},
+        'break-finished': {},
+        'take-break': {},
+    },
+}, class BreakManager extends GObject.Object {
+    constructor(clock, idleMonitor, settingsFactory) {
+        super();
+
+        // Allow these two bits of global state to be overridden for unit testing
+        this._idleMonitor = idleMonitor ?? global.backend.get_core_idle_monitor();
+        this._clock = clock ?? {
+            sourceRemove: GLib.source_remove,
+            getRealTimeSecs: () => {
+                return GLib.get_real_time() / GLib.USEC_PER_SEC;
+            },
+            timeoutAddSeconds: GLib.timeout_add_seconds,
+        };
+        this._settingsFactory = settingsFactory ?? {
+            new: Gio.Settings.new,
+            newWithPath: Gio.Settings.new_with_path,
+        };
+        this._breakSettings = this._settingsFactory.new('org.gnome.desktop.break-reminders');
+        this._breakSettings.connect('changed', () => this._updateSettings());
+
+        this._state = BreakState.DISABLED;
+        this._breakTypeSettings = new Map();  // map of breakType to GSettings
+        this._breakTypeSettingsChangedId = new Map();
+        this._breakLastEnd = new Map();  // map of breakType to wall clock time (in seconds)
+        this._idleWatchId = 0;
+        this._activeWatchId = 0;
+        this._timerId = 0;
+
+        // Start tracking timings
+        this._updateSettings();
+    }
+
+    _updateSettings() {
+        // Load settings for each enabled type of break
+        const selectedBreaks = this._breakSettings.get_strv('selected-breaks');
+
+        if (!selectedBreaks || !selectedBreaks.length) {
+            this._stopStateMachine();
+            return false;
+        }
+
+        const currentTime = this.getCurrentTime();
+
+        for (const breakType of SUPPORTED_BREAK_TYPES) {
+            if (selectedBreaks.includes(breakType) &&
+                !this._breakTypeSettings.has(breakType)) {
+                // Enabling a previously disabled break
+                const breakSettings = this._settingsFactory.newWithPath(
+                    `org.gnome.desktop.break-reminders.${breakType}`,
+                    `${this._breakSettings.path}${breakType}/`);
+                this._breakTypeSettings.set(breakType, breakSettings);
+                this._breakTypeSettingsChangedId.set(breakType,
+                    breakSettings.connect('changed', () => this._updateState(this.getCurrentTime())));
+                this._breakLastEnd.set(breakType, currentTime);
+            } else if (!selectedBreaks.includes(breakType) &&
+                       this._breakTypeSettings.has(breakType)) {
+                // Disabling a previously enabled break
+                const breakSettings = this._breakTypeSettings.get(breakType);
+                breakSettings.disconnect(this._breakTypeSettingsChangedId.get(breakType));
+                this._breakTypeSettings.delete(breakType);
+                this._breakTypeSettingsChangedId.delete(breakType);
+                this._breakLastEnd.delete(breakType);
+            }
+        }
+
+        this.notify('next-break-due-time');
+
+        if (this._state === BreakState.DISABLED)
+            this._startStateMachine();
+
+        return true;
+    }
+
+    _startStateMachine() {
+        this._idleWatchId = this._idleMonitor.add_idle_watch(MIN_BREAK_LENGTH_SECONDS * 1000, this._onIdleWatch.bind(this));
+
+        this._state = BreakState.ACTIVE;
+        this._currentBreakType = null;
+        this._currentBreakStartTime = 0;
+        this._idleState = IdleState.ACTIVE;
+        this._idleStartTime = 0;
+
+        this._updateState(this.getCurrentTime());
+    }
+
+    _stopStateMachine() {
+        if (this._idleWatchId !== 0)
+            this._idleMonitor.remove_watch(this._idleWatchId);
+        this._idleWatchId = 0;
+
+        if (this._activeWatchId !== 0)
+            this._idleMonitor.remove_watch(this._activeWatchId);
+        this._activeWatchId = 0;
+
+        if (this._timerId !== 0)
+            this._clock.sourceRemove(this._timerId);
+        this._timerId = 0;
+
+        for (const [breakType, breakSettings] of this._breakTypeSettings)
+            breakSettings.disconnect(this._breakTypeSettingsChangedId.get(breakType));
+        this._breakTypeSettings = new Map();
+        this._breakTypeSettingsChangedId = new Map();
+        this._breakLastEnd = new Map();
+
+        this._state = BreakState.DISABLED;
+        this._currentBreakType = null;
+        this._currentBreakStartTime = 0;
+        this._idleState = IdleState.ACTIVE;
+        this._idleStartTime = 0;
+
+        this.notify('state');
+    }
+
+    _onIdleWatch() {
+        console.assert(this._state !== BreakState.DISABLED, 'Idle received when manager is disabled');
+
+        const currentTime = this.getCurrentTime();
+
+        console.debug(`BreakManager: _onIdleWatch at ${currentTime}s`);
+
+        // Start watching to see if the user becomes active again.
+        if (this._activeWatchId === 0)
+            this._activeWatchId = this._idleMonitor.add_user_active_watch(this._onUserActiveWatch.bind(this));
+
+        this._idleState = IdleState.IDLE;
+        this._idleStartTime = currentTime - MIN_BREAK_LENGTH_SECONDS;  /* already waited MIN_BREAK_LENGTH_SECONDS for the idle watch */
+        this._updateState(currentTime);
+    }
+
+    _onUserActiveWatch() {
+        console.assert(this._state !== BreakState.DISABLED, 'Active received when manager is disabled');
+
+        const currentTime = this.getCurrentTime();
+
+        console.debug(`BreakManager: _onUserActiveWatch at ${currentTime}s`);
+
+        // Tidy up after the active watch, which is one-shot.
+        // (The idle watch stays installed until explicitly removed.)
+        this._idleMonitor.remove_watch(this._activeWatchId);
+        this._activeWatchId = 0;
+
+        this._idleState = IdleState.ACTIVE;
+        this._updateState(currentTime);
+    }
+
+    /**
+     * Get the current real time, in seconds since the Unix epoch.
+     *
+     * @returns {number}
+     */
+    getCurrentTime() {
+        return this._clock.getRealTimeSecs();
+    }
+
+    _updateState(currentTime) {
+        const idleTimeSeconds = currentTime - this._idleStartTime;
+
+        if (this._idleState === IdleState.IDLE) {
+            // What kind of break are we due, if any?
+            const [dueBreakType, nextDueTime] = this.getNextBreakDue(currentTime);
+            let inBreak = false;
+            let dueBreakStartTime = 0;
+
+            if (dueBreakType != null && nextDueTime <= currentTime) {
+                const dueBreakTypeDuration = this._breakTypeSettings.get(dueBreakType).get_uint('duration-seconds');
+
+                // Has the break been finished?
+                if (nextDueTime + dueBreakTypeDuration <= currentTime) {
+                    if (this._state === BreakState.IN_BREAK)
+                        this.emit('break-finished');
+                } else {
+                    // Start a timer to announce the end of the break.
+                    inBreak = true;
+                    dueBreakStartTime = nextDueTime;
+                    this._scheduleUpdateState(nextDueTime + dueBreakTypeDuration - currentTime);
+                }
+            } else if (nextDueTime !== 0) {
+                // The user is idle but is not due a break. Store up the idle time
+                // and update the break end times when the user next becomes active.
+                // But also schedule a timer to notify the user about the start of
+                // their scheduled break.
+                console.assert(nextDueTime > currentTime, `nextDueTime (${nextDueTime}) should be greater than currentTime (${currentTime})`);
+                this._scheduleUpdateState(nextDueTime - currentTime);
+            }
+
+            const newState = inBreak ? BreakState.IN_BREAK : BreakState.IDLE;
+            if (this._state !== newState) {
+                console.debug(`BreakManager: Changing state to ${breakStateToString(newState)}`);
+                this._state = newState;
+                this._currentBreakType = dueBreakType;
+                this._currentBreakStartTime = dueBreakStartTime;
+                this.notify('state');
+            }
+        } else if (this._idleState === IdleState.ACTIVE) {
+            let emitBreakDue = false;
+
+            this.freeze_notify();
+
+            // How long was the user idle before becoming active? Use that to reset the break end times.
+            // Reset every break type which is shorter than the idle time. This allows
+            // the user to start a break a little early, or take an unexpected break,
+            // and avoid their computer pestering them to take a break on schedule
+            // shortly after they get back.
+            console.debug(`BreakManager: idleStartTime: ${this._idleStartTime}s`);
+
+            if (this._idleStartTime > 0) {
+                for (const [breakType, breakTypeSettings] of this._breakTypeSettings) {
+                    const breakTypeDuration = breakTypeSettings.get_uint('duration-seconds');
+
+                    if (idleTimeSeconds >= breakTypeDuration)
+                        this._breakLastEnd.set(breakType, currentTime);
+                }
+
+                this.notify('next-break-due-time');
+                this.notify('last-break-end-time');
+            }
+
+            // Reset the idle start time state.
+            this._idleStartTime = 0;
+
+            // Are any breaks due now?
+            const [dueBreakType, nextDueTime] = this.getNextBreakDue(currentTime);
+            const isBreakDue = dueBreakType != null && nextDueTime <= currentTime;
+
+            console.debug(`BreakManager: nextDueTime: ${nextDueTime}s, currentTime: ${currentTime}s, dueBreakType: ${dueBreakType}`);
+
+            if (isBreakDue) {
+                // Notify that a break is due if we haven’t done so already.
+                if (this._state === BreakState.ACTIVE) {
+                    this._state = BreakState.BREAK_DUE;
+                    this._currentBreakType = dueBreakType;
+                    this._currentBreakStartTime = nextDueTime;
+                    this.notify('state');
+                    emitBreakDue = true;
+                }
+            } else {
+                if (nextDueTime !== 0) {
+                    // Set a timer for when the next break is due.
+                    console.assert(nextDueTime > currentTime, `nextDueTime (${nextDueTime}) should be greater than currentTime (${currentTime})`);
+                    this._scheduleUpdateState(nextDueTime - currentTime);
+                }
+
+                if (this._state !== BreakState.ACTIVE) {
+                    console.debug('BreakManager: Changing state to ACTIVE');
+                    this._state = BreakState.ACTIVE;
+                    this._currentBreakType = null;
+                    this._currentBreakStartTime = 0;
+                    this.notify('state');
+                }
+            }
+
+            this.thaw_notify();
+            if (emitBreakDue)
+                this.emit('break-due');
+        }
+    }
+
+    _scheduleUpdateState(timeout) {
+        if (this._timerId !== 0)
+            this._clock.sourceRemove(this._timerId);
+
+        // Round up to avoid spinning
+        const timeoutSeconds = Math.ceil(timeout);
+
+        console.debug(`BreakManager: Scheduling state update in ${timeoutSeconds}s`);
+
+        this._timerId = this._clock.timeoutAddSeconds(GLib.PRIORITY_DEFAULT, timeoutSeconds, () => {
+            this._timerId = 0;
+            console.debug('BreakManager: Scheduled state update');
+            this._updateState(this.getCurrentTime());
+            return GLib.SOURCE_REMOVE;
+        });
+    }
+
+    /**
+     * Get a tuple of information about the break type which is currently due or
+     * which will be due next, relative to `fromTime`.
+     *  1. The type of break which is currently due or which will be due next,
+     *     or `null` if no break is due.
+     *  2. The time when the next break is due; if the first member of the tuple
+     *     is non-null, then this is the time when that break was due. This is
+     *     zero if no break types are enabled.
+     *
+     * @param {number} fromTime ‘Current’ time to calculate from
+     * @returns {Array} Two-element array of {?string} break type and {number} next due time
+     */
+    getNextBreakDue(fromTime) {
+        let maxDuration = 0;
+        let maxDurationType = null;
+        let dueBreakTypes = [];
+        let nextDueTime = 0;
+
+        console.debug(`BreakManager: Current time: ${fromTime}s`);
+
+        for (const [breakType, breakTypeSettings] of this._breakTypeSettings) {
+            const breakTypeInterval = breakTypeSettings.get_uint('interval-seconds');
+            const breakTypeDuration = breakTypeSettings.get_uint('duration-seconds');
+
+            // Work out which break types are now due.
+            const breakTypeWouldBeDueAt = this._breakLastEnd.get(breakType) + breakTypeInterval;
+            const breakTypeIsDue = breakTypeWouldBeDueAt <= fromTime;
+
+            console.debug(`BreakManager: Break type ${breakType}: would be due at ${breakTypeWouldBeDueAt}`);
+
+            if (breakTypeIsDue) {
+                dueBreakTypes.push(breakType);
+                nextDueTime = breakTypeWouldBeDueAt;
+
+                // Of the break types which are now due, which has the longest
+                // duration?
+                if (maxDuration === 0 || breakTypeDuration > maxDuration) {
+                    maxDuration = breakTypeDuration;
+                    maxDurationType = breakType;
+                }
+            } else if (nextDueTime === 0 || nextDueTime > breakTypeWouldBeDueAt) {
+                // This break type isn’t due now, but when is it due?
+                nextDueTime = breakTypeWouldBeDueAt;
+                maxDurationType = breakType;
+            }
+        }
+
+        console.debug(`BreakManager: Due break types: ${dueBreakTypes.length ? dueBreakTypes : '(none)'}, max duration type: ${maxDurationType}, next due time: ${nextDueTime}`);
+
+        // maxDurationType is null and nextDueTime is zero if no break types are enabled
+        return [maxDurationType, nextDueTime];
+    }
+
+    /**
+     * Current state machine state.
+     *
+     * @type {BreakState}
+     */
+    get state() {
+        return this._state;
+    }
+
+    /**
+     * String identifier for the break type which is currently in progress or
+     * due.
+     *
+     * Returns `null` if no break is currently due or happening.
+     *
+     * @type {?string}
+     */
+    get currentBreakType() {
+        return this._currentBreakType;
+    }
+
+    /**
+     * Start time for the break which is currently in progress , or `0` if no
+     * break is in progress.
+     *
+     * If the user was idle before the break started, the start time will be
+     * during the idle period, not the start of the idle period.
+     *
+     * @type {number}
+     */
+    get currentBreakStartTime() {
+        if (this.state !== BreakState.IN_BREAK)
+            return 0;
+
+        return this._currentBreakStartTime;
+    }
+
+    /**
+     * End time for the most recent break, or `0` if a break is currently in
+     * progress or no breaks have happened yet.
+     *
+     * @type {number}
+     */
+    get lastBreakEndTime() {
+        if (this.state === BreakState.IN_BREAK)
+            return 0;
+
+        return Math.max(0, ...this._breakLastEnd.values());
+    }
+
+    /**
+     * Get the time when the next break is due. If a break is currently due,
+     * then this is the time when that break was due. This is zero if no break
+     * types are enabled.
+     *
+     * @param {number} fromTime ‘Current’ time to calculate from
+     * @returns {number}
+     */
+    getNextBreakDueTime(fromTime) {
+        const [, nextDueTime] = this.getNextBreakDue(fromTime);
+        return nextDueTime;
+    }
+
+    /**
+     * Time when the next break is due. This is the result of calling
+     * `getNextBreakDueTime()` with the current time.
+     *
+     * @type {number}
+     */
+    get nextBreakDueTime() {
+        const currentTime = this.getCurrentTime();
+        return this.getNextBreakDueTime(currentTime);
+    }
+
+    /**
+     * Delays the currently upcoming, due or in-progress break.
+     *
+     * This is a no-op if no break is currently due or in progress.
+     */
+    delayBreak() {
+        if (this.state === BreakState.DISABLED)
+            return;
+
+        const currentTime = this.getCurrentTime();
+
+        console.debug('BreakManager: Delaying current break');
+
+        // Increment all the last break end times for any break types which are
+        // currently due by the delay. We do this over all break types, rather
+        // than just the current break type, because multiple break types might
+        // be due at the same time, and it would be a bit annoying to delay one
+        // only for another break type to immediately become due.
+        for (const [breakType, breakTypeSettings] of this._breakTypeSettings) {
+            const breakTypeInterval = breakTypeSettings.get_uint('interval-seconds');
+            const delaySecs = breakTypeSettings.get_uint('delay-seconds');
+
+            if (this._breakLastEnd.get(breakType) + breakTypeInterval <= currentTime)
+                this._breakLastEnd.set(breakType, this._breakLastEnd.get(breakType) + delaySecs);
+        }
+
+        this.freeze_notify();
+        this.notify('next-break-due-time');
+        this.notify('last-break-end-time');
+
+        this._updateState(currentTime);
+        this.thaw_notify();
+    }
+
+    /**
+     * Skips the currently upcoming, due or in-progress break.
+     *
+     * This is a no-op if no break is currently due or in progress.
+     */
+    skipBreak() {
+        if (this.state === BreakState.DISABLED)
+            return;
+
+        const currentTime = this.getCurrentTime();
+
+        console.debug('BreakManager: Skipping current break');
+
+        // Set all the last break end times for any break types which are
+        // currently due to now. We do this over all break types, rather than
+        // just the current break type, because multiple break types might be
+        // due at the same time, and it would be a bit annoying to skip one only
+        // for another break type to immediately become due.
+        for (const [breakType, breakTypeSettings] of this._breakTypeSettings) {
+            const breakTypeInterval = breakTypeSettings.get_uint('interval-seconds');
+
+            if (this._breakLastEnd.get(breakType) + breakTypeInterval <= currentTime)
+                this._breakLastEnd.set(breakType, currentTime);
+        }
+
+        this.freeze_notify();
+        this.notify('next-break-due-time');
+        this.notify('last-break-end-time');
+
+        this._updateState(currentTime);
+        this.thaw_notify();
+    }
+
+    /**
+     * Signals that the user explicitly wants to start taking a break now, even
+     * if they are technically still active.
+     */
+    takeBreak() {
+        // We can’t force the user to be idle, but we can indicate to the break
+        // dispatcher that it should try and make the user be idle.
+        this.emit('take-break');
+    }
+
+    /**
+     * Whether the given breakType should emit notification popups to the user.
+     *
+     * @param {string} breakType
+     * @returns {boolean}
+     */
+    breakTypeShouldNotify(breakType) {
+        if (!this._breakTypeSettings.has(breakType))
+            return false;
+        return this._breakTypeSettings.get(breakType).get_boolean('notify');
+    }
+
+    /**
+     * Whether the given breakType should emit notification popups to the user
+     * when it’s upcoming.
+     *
+     * @param {string} breakType
+     * @returns {boolean}
+     */
+    breakTypeShouldNotifyUpcoming(breakType) {
+        if (!this._breakTypeSettings.has(breakType))
+            return false;
+        return this._breakTypeSettings.get(breakType).get_boolean('notify-upcoming');
+    }
+
+    /**
+     * Whether the given breakType should emit notification popups to the user
+     * if it’s overdue.
+     *
+     * @param {string} breakType
+     * @returns {boolean}
+     */
+    breakTypeShouldNotifyOverdue(breakType) {
+        if (!this._breakTypeSettings.has(breakType))
+            return false;
+        return this._breakTypeSettings.get(breakType).get_boolean('notify-overdue');
+    }
+
+    /**
+     * Whether the given breakType should show a prominent countdown for the
+     * last 60s before it’s due.
+     *
+     * @param {string} breakType
+     * @returns {boolean}
+     */
+    breakTypeShouldCountdown(breakType) {
+        if (!this._breakTypeSettings.has(breakType))
+            return false;
+        return this._breakTypeSettings.get(breakType).get_boolean('countdown');
+    }
+
+    /**
+     * Whether the given breakType should play sounds to notify the user.
+     *
+     * @param {string} breakType
+     * @returns {boolean}
+     */
+    breakTypeShouldPlaySound(breakType) {
+        if (!this._breakTypeSettings.has(breakType))
+            return false;
+        return this._breakTypeSettings.get(breakType).get_boolean('play-sound');
+    }
+
+    /**
+     * Whether the given breakType should fade the screen during breaks.
+     *
+     * @param {string} breakType
+     * @returns {boolean}
+     */
+    breakTypeShouldFadeScreen(breakType) {
+        if (!this._breakTypeSettings.has(breakType))
+            return false;
+        return this._breakTypeSettings.get(breakType).get_boolean('fade-screen');
+    }
+
+    /**
+     * Whether the given breakType should lock the screen during breaks.
+     *
+     * @param {string} breakType
+     * @returns {boolean}
+     */
+    breakTypeShouldLockScreen(breakType) {
+        if (!this._breakTypeSettings.has(breakType))
+            return false;
+        return this._breakTypeSettings.get(breakType).get_boolean('lock-screen');
+    }
+
+    /**
+     * Duration (in seconds) of the given breakType.
+     *
+     * @param {string} breakType
+     * @returns {number}
+     */
+    getDurationForBreakType(breakType) {
+        if (!this._breakTypeSettings.has(breakType))
+            return 0;
+        return this._breakTypeSettings.get(breakType).get_uint('duration-seconds');
+    }
+});
+
+/**
+ * Glue class which takes the state-based output from BreakManager and converts
+ * it to event-based notifications/sounds/screen fades for the user to tell them
+ * when to take breaks. It factors the user’s UI preferences into account.
+ */
+export const BreakDispatcher = GObject.registerClass(
+class BreakDispatcher extends GObject.Object {
+    constructor(manager) {
+        super();
+
+        this._manager = manager;
+        this._previousState = BreakState.DISABLED;
+        this._previousBreakType = null;
+        this._manager.connectObject(
+            'take-break', this._onTakeBreak.bind(this),
+            'notify::state', this._onStateChanged.bind(this),
+            'notify::next-break-due-time', this._onStateChanged.bind(this),
+            this);
+
+        this._systemActions = SystemActions.getDefault();
+
+        this._notificationSource = null;
+        this._lightbox = null;
+        this._countdownOsd = null;
+        this._countdownTimerId = 0;
+
+        if (this._manager.state === BreakState.DISABLED)
+            this._ensureDisabled();
+        else
+            this._ensureEnabled();
+    }
+
+    destroy() {
+        this._ensureDisabled();
+
+        this._manager.disconnectObject(this);
+        this._manager = null;
+    }
+
+    _ensureEnabled() {
+        if (this._notificationSource === null)
+            this._notificationSource = new BreakNotificationSource(this._manager);
+
+        if (this._lightbox === null) {
+            this._lightbox = new Lightbox.Lightbox(Main.uiGroup, {
+                inhibitEvents: false,
+                fadeFactor: LIGHTBOX_FADE_FACTOR,
+            });
+        }
+    }
+
+    _ensureDisabled() {
+        this._notificationSource?.destroy();
+        this._notificationSource = null;
+
+        this._lightbox?.destroy();
+        this._lightbox = null;
+
+        this._removeCountdown();
+
+        this._previousState = BreakState.DISABLED;
+        this._previousBreakType = null;
+    }
+
+    _maybePlayCompleteSound() {
+        if (this._manager.breakTypeShouldPlaySound(this._previousBreakType)) {
+            const player = global.display.get_sound_player();
+
+            player.play_from_theme('complete', _('Break complete sound'), null);
+        }
+    }
+
+    _removeCountdown() {
+        if (this._countdownTimerId !== 0)
+            GLib.source_remove(this._countdownTimerId);
+        this._countdownTimerId = 0;
+
+        this._countdownOsd?.destroy();
+        this._countdownOsd = null;
+    }
+
+    _onStateChanged() {
+        switch (this._manager.state) {
+        case BreakState.DISABLED:
+            this._ensureDisabled();
+            break;
+
+        case BreakState.ACTIVE: {
+            this._ensureEnabled();
+
+            if (this._previousState === BreakState.IN_BREAK)
+                this._maybePlayCompleteSound();
+
+            this._lightbox?.lightOff();
+
+            // Work out when the next break is due, and schedule a countdown.
+            const currentTime = this._manager.getCurrentTime();
+            const [nextBreakType, nextBreakDueTime] = this._manager.getNextBreakDue(currentTime);
+
+            if (this._manager.breakTypeShouldCountdown(nextBreakType)) {
+                const dueInSeconds = nextBreakDueTime - currentTime;
+                const countdownStart = Math.max(dueInSeconds - BREAK_COUNTDOWN_TIME_SECONDS, 0);
+                console.debug(`BreakDispatcher: Scheduling break countdown to start in ${countdownStart}s`);
+
+                if (this._countdownTimerId !== 0)
+                    GLib.source_remove(this._countdownTimerId);
+                this._countdownTimerId = GLib.timeout_add_seconds(
+                    GLib.PRIORITY_DEFAULT,
+                    countdownStart, () => {
+                        if (this._countdownOsd == null) {
+                            this._countdownOsd = new OsdBreakCountdownLabel(this._manager);
+                            this._countdownOsd.connect('destroy',
+                                () => (this._countdownOsd = null));
+                        }
+                        this._countdownTimerId = 0;
+                        return GLib.SOURCE_REMOVE;
+                    });
+            }
+
+            break;
+        }
+
+        case BreakState.IDLE: {
+            this._ensureEnabled();
+
+            if (this._previousState === BreakState.IN_BREAK)
+                this._maybePlayCompleteSound();
+
+            this._lightbox?.lightOff();
+
+            break;
+        }
+
+        case BreakState.IN_BREAK: {
+            this._ensureEnabled();
+
+            if (this._manager.breakTypeShouldLockScreen(this._manager.currentBreakType) &&
+                this._previousState !== BreakState.IN_BREAK) {
+                this._systemActions.activateLockScreen();
+            } else if (this._manager.breakTypeShouldFadeScreen(this._manager.currentBreakType)) {
+                Main.uiGroup.set_child_above_sibling(this._lightbox, null);
+                this._lightbox.lightOn(LIGHTBOX_FADE_TIME_SECONDS * 1000);
+            }
+
+            this._removeCountdown();
+
+            break;
+        }
+
+        case BreakState.BREAK_DUE: {
+            this._ensureEnabled();
+            this._removeCountdown();
+
+            break;
+        }
+        default:
+            console.assert(false, `Unknown BreakManager state: ${this._manager.state}`);
+            break;
+        }
+
+        this._previousState = this._manager.state;
+        this._previousBreakType = this._manager.currentBreakType;
+    }
+
+    _onTakeBreak() {
+        if (this._manager.breakTypeShouldLockScreen(this._manager.currentBreakType)) {
+            this._systemActions.activateLockScreen();
+        } else if (this._manager.breakTypeShouldFadeScreen(this._manager.currentBreakType)) {
+            Main.uiGroup.set_child_above_sibling(this._lightbox, null);
+            this._lightbox.lightOn(LIGHTBOX_FADE_TIME_SECONDS * 1000);
+        }
+    }
+});
+
+/* This can’t directly extend MessageTray.Source (even though it conceptually
+ * does) because the Shell will automatically destroy a MessageTray.Source when
+ * it becomes empty, and that destroys the state which we need for correctly
+ * showing the next notification (e.g. on a timer).
+ *
+ * So instead this is a normal GObject which closely wraps a MessageTray.Source.
+ */
+const BreakNotificationSource = GObject.registerClass(
+class BreakNotificationSource extends GObject.Object {
+    constructor(manager) {
+        super();
+
+        this._app = Shell.AppSystem.get_default().lookup_app('gnome-wellbeing-panel.desktop');
+        this._source = null;
+
+        this._notification = null;
+        this._timerId = 0;
+        this._manager = manager;
+        this._manager.connectObject(
+            'notify::state', this._onStateChanged.bind(this),
+            'notify::next-break-due-time', this._onStateChanged.bind(this),
+            this);
+
+        this._previousState = BreakState.DISABLED;
+        this._previousBreakType = null;
+        this._updateState();
+    }
+
+    destroy() {
+        this._notification?.destroy();
+        this._notification = null;
+
+        this._source?.destroy();
+        this._source = null;
+
+        if (this._timerId !== 0)
+            GLib.source_remove(this._timerId);
+        this._timerId = 0;
+
+        this._manager?.disconnectObject(this);
+        this._manager = null;
+
+        this.run_dispose();
+    }
+
+    _ensureNotification(params) {
+        if (!this._source) {
+            this._source = new MessageTray.Source({
+                title: this._app.get_name(),
+                icon: this._app.get_icon(),
+                policy: MessageTray.NotificationPolicy.newForApp(this._app),
+            });
+            this._source.connect('destroy', () => (this._source = null));
+            Main.messageTray.add(this._source);
+        }
+
+        if (this._notification === null) {
+            this._notification = new BreakNotification(this._source, this._manager);
+            this._notification.connect('destroy', () => (this._notification = null));
+        }
+
+        // Unacknowledge the notification when it’s updated, by default.
+        this._notification.set({acknowledged: false, ...params});
+    }
+
+    /**
+     * Formats the given time span as a translated string including units.
+     * Returns a tuple of:
+     *  1. The translated string
+     *  2. The number which determines whether the string is plural, for use
+     *     with `ngettext()` calls to embed the returned string into (as they
+     *     will need plural handling).
+     *  3. The number of seconds until the string would change if re-generated,
+     *     for use in setting a timer to update a notification.
+     *
+     * @param {number} secondsAgo
+     *
+     * @returns {Array}
+     */
+    _formatTimeSpan(secondsAgo) {
+        const minutesAgo = secondsAgo / 60;
+        const hoursAgo = minutesAgo / 60;
+        const daysAgo = hoursAgo / 24;
+
+        if (secondsAgo < 60) {
+            return [Gettext.ngettext(
+                '%d second',
+                '%d seconds',
+                secondsAgo
+            ).format(secondsAgo), secondsAgo, 1];
+        } else if (minutesAgo < 60) {
+            return [Gettext.ngettext(
+                '%d minute',
+                '%d minutes',
+                minutesAgo
+            ).format(minutesAgo), minutesAgo, 60 - (secondsAgo % 60)];
+        } else if (hoursAgo < 24) {
+            return [Gettext.ngettext(
+                '%d hour',
+                '%d hours',
+                hoursAgo
+            ).format(hoursAgo), hoursAgo, (60 - (minutesAgo % 60)) * 60];
+        } else {
+            return [Gettext.ngettext(
+                '%d day',
+                '%d days',
+                daysAgo
+            ).format(daysAgo), daysAgo, (24 - (hoursAgo % 24)) * 60 * 60];
+        }
+    }
+
+    _onStateChanged() {
+        this._updateState();
+
+        this._previousState = this._manager.state;
+        this._previousBreakType = this._manager.currentBreakType;
+    }
+
+    _urgencyForBreakType(breakType) {
+        console.assert(breakType != null, 'null breakType');
+
+        // While the preferences indicate that certain break types should notify
+        // and others shouldn’t, that’s not really possible: we only have one
+        // notification which gets constantly updated with information about
+        // breaks, interleaving messages from multiple breaks over time. Having
+        // it disappear and reappear for different break types would be jarring.
+        // Instead, set the urgency to LOW for break types which shouldn’t
+        // notify. This means the notification remains, but is not presented to
+        // the user — it’s just visible if they expand the message tray.
+        if (this._manager.breakTypeShouldNotify(breakType))
+            return MessageTray.Urgency.HIGH;
+        else
+            return MessageTray.Urgency.LOW;
+    }
+
+    _scheduleUpdateState(timeout) {
+        if (this._timerId !== 0)
+            GLib.source_remove(this._timerId);
+
+        // Round up to avoid spinning
+        const timeoutSeconds = Math.ceil(timeout);
+
+        console.debug(`BreakNotificationSource: Scheduling notification state update in ${timeoutSeconds}s`);
+
+        this._timerId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, timeoutSeconds, () => {
+            this._timerId = 0;
+            console.debug('BreakNotificationSource: Scheduled notification state update');
+            this._updateState();
+            return GLib.SOURCE_REMOVE;
+        });
+    }
+
+    _updateState() {
+        const currentTime = this._manager.getCurrentTime();
+
+        console.debug(`BreakNotificationSource: Break notification source state changed from ${breakStateToString(this._previousState)} to ${breakStateToString(this._manager.state)}`);
+
+        switch (this._manager.state) {
+        case BreakState.DISABLED:
+            this.destroy();
+            break;
+
+        case BreakState.ACTIVE: {
+            if (this._previousState === BreakState.IN_BREAK) {
+                // Break is complete.
+                this._notification?.destroy(MessageTray.NotificationDestroyedReason.SOURCE_CLOSED);
+            } else {
+                // Work out when the next break is due, and display some warnings
+                // that it’s impending.
+                const [nextBreakType, nextBreakDueTime] = this._manager.getNextBreakDue(currentTime);
+                const remainingSecs = nextBreakDueTime - currentTime;
+                console.debug(`BreakNotificationSource: ${remainingSecs}s left before next break`);
+
+                if (this._manager.breakTypeShouldNotifyUpcoming(nextBreakType)) {
+                    for (const notificationTime of BREAK_UPCOMING_NOTIFICATION_TIME_SECONDS) {
+                        console.debug(`BreakNotificationSource: Considering upcoming notification ${notificationTime}s`);
+
+                        if (remainingSecs > notificationTime) {
+                            // Schedule to show the first (longest) notification
+                            // which is less than the remaining time before the break.
+                            this._scheduleUpdateState(remainingSecs - notificationTime);
+                            break;
+                        } else if (Math.ceil(remainingSecs) === notificationTime) {
+                            // Bang on time to show this notification.
+                            const [remainingText, remainingValue, unused] = this._formatTimeSpan(remainingSecs);
+                            const bodyText = Gettext.ngettext(
+                                /* %s will be replaced with a string that describes a time interval, such as “2 minutes”, “40 seconds” or “1 hour” */
+                                'It will be time for a break in %s',
+                                'It will be time for a break in %s',
+                                remainingValue
+                            ).format(remainingText);
+
+                            let titleText;
+                            switch (nextBreakType) {
+                            case 'movement':
+                                titleText = _('Movement Break Soon');
+                                break;
+                            case 'eyesight':
+                                titleText = _('Eyesight Break Soon');
+                                break;
+                            default:
+                                titleText = _('Break Soon');
+                                break;
+                            }
+
+                            this._ensureNotification({
+                                title: titleText,
+                                body: bodyText,
+                                sound: null,
+                                allowDelay: true,
+                                allowSkip: true,
+                                allowTake: false,
+                            });
+                            this._source.addNotification(this._notification);
+
+                            // Continue to the next iteration, to schedule an update
+                            // for the next-biggest notification.
+                            continue;
+                        }
+                    }
+                }
+            }
+            break;
+        }
+
+        case BreakState.IDLE: {
+            // Do nothing.
+            break;
+        }
+
+        case BreakState.IN_BREAK: {
+            // Clamp to a minimum of 1s because saying “you have 0 seconds left”
+            // is a bit odd. The state machine may remain in
+            // `BreakState.IN_BREAK` even after the break duration is over,
+            // because the user might remain idle. In that case, display no
+            // further notifications until the user becomes active again — at
+            // that point they get a ‘Break is over’ notification.
+            const breakDurationRemaining = this._manager.getDurationForBreakType(this._manager.currentBreakType) - (currentTime - this._manager.currentBreakStartTime);
+
+            if (breakDurationRemaining >= 1) {
+                const [remainingText, remainingValue, updateTimeoutSeconds] = this._formatTimeSpan(breakDurationRemaining);
+
+                let titleText, bodyText;
+                switch (this._manager.currentBreakType) {
+                case 'movement':
+                    titleText = _('Movement Break in Progress');
+                    bodyText = Gettext.ngettext(
+                        /* %s will be replaced with a string that describes a time interval, such as “2 minutes”, “40 seconds” or “1 hour” */
+                        'Continue moving around for %s',
+                        'Continue moving around for %s',
+                        remainingValue
+                    ).format(remainingText);
+                    break;
+                case 'eyesight':
+                    titleText = _('Eyesight Break in Progress');
+                    bodyText = Gettext.ngettext(
+                        /* %s will be replaced with a string that describes a time interval, such as “2 minutes”, “40 seconds” or “1 hour” */
+                        'Continue looking away for %s',
+                        'Continue looking away for %s',
+                        remainingValue
+                    ).format(remainingText);
+                    break;
+                default:
+                    titleText = _('Break in Progress');
+                    bodyText = Gettext.ngettext(
+                        /* %s will be replaced with a string that describes a time interval, such as “2 minutes”, “40 seconds” or “1 hour” */
+                        '%s left in your break',
+                        '%s left in your break',
+                        remainingValue
+                    ).format(remainingText);
+                    break;
+                }
+
+                this._ensureNotification({
+                    title: titleText,
+                    body: bodyText,
+                    sound: null,
+                    urgency: this._urgencyForBreakType(this._manager.currentBreakType),
+                    allowDelay: false,
+                    allowSkip: false,
+                    allowTake: false,
+                });
+                this._source.addNotification(this._notification);
+
+                this._scheduleUpdateState(updateTimeoutSeconds);
+            } else {
+                this._notification?.destroy(MessageTray.NotificationDestroyedReason.SOURCE_CLOSED);
+            }
+            break;
+        }
+
+        case BreakState.BREAK_DUE: {
+            const [nextBreakType, nextBreakDueTime] = this._manager.getNextBreakDue(currentTime);
+            const breakDueAgo = currentTime - nextBreakDueTime;
+            console.assert(breakDueAgo >= 0, 'breakDueAgo should be non-negative');
+
+            if ((this._previousState === BreakState.ACTIVE ||
+                 this._previousState === BreakState.DISABLED) &&
+                breakDueAgo < BREAK_OVERDUE_TIME_SECONDS) {
+                const durationSecs = this._manager.getDurationForBreakType(this._manager.currentBreakType);
+                const [durationText, durationValue, unused] = this._formatTimeSpan(durationSecs);
+
+                let titleText, bodyText;
+                switch (this._manager.currentBreakType) {
+                case 'movement':
+                    titleText = _('Time for a Movement Break');
+                    bodyText = Gettext.ngettext(
+                        /* %s will be replaced with a string that describes a time interval, such as “2 minutes”, “40 seconds” or “1 hour” */
+                        'Take a break from the device and move around for %s',
+                        'Take a break from the device and move around for %s',
+                        durationValue
+                    ).format(durationText);
+                    break;
+                case 'eyesight':
+                    titleText = _('Time for an Eyesight Break');
+                    bodyText = Gettext.ngettext(
+                        /* %s will be replaced with a string that describes a time interval, such as “2 minutes”, “40 seconds” or “1 hour” */
+                        'Take a break from the screen. Look at least 6 meters away for at least %s',
+                        'Take a break from the screen. Look at least 6 meters away for at least %s',
+                        durationValue
+                    ).format(durationText);
+                    break;
+                default:
+                    titleText = _('Time for a Break');
+                    bodyText = Gettext.ngettext(
+                        /* %s will be replaced with a string that describes a time interval, such as “2 minutes”, “40 seconds” or “1 hour” */
+                        'It’s time to take a break. Get away from the device for %s!',
+                        'It’s time to take a break. Get away from the device for %s!',
+                        durationValue
+                    ).format(durationText);
+                    break;
+                }
+
+                this._ensureNotification({
+                    title: titleText,
+                    body: bodyText,
+                    sound: null,
+                    allowDelay: true,
+                    allowSkip: false,
+                    allowTake: true,
+                });
+
+                this._scheduleUpdateState(BREAK_OVERDUE_TIME_SECONDS);
+            } else if (breakDueAgo >= BREAK_OVERDUE_TIME_SECONDS &&
+                       this._manager.breakTypeShouldNotifyOverdue(nextBreakType)) {
+                const [delayText, delayValue, updateTimeoutSeconds] = this._formatTimeSpan(breakDueAgo);
+                const bodyText = Gettext.ngettext(
+                    /* %s will be replaced with a string that describes a time interval, such as “2 minutes”, “40 seconds” or “1 hour” */
+                    'You were due to take a break %s ago',
+                    'You were due to take a break %s ago',
+                    delayValue
+                ).format(delayText);
+
+                this._ensureNotification({
+                    title: _('Break Overdue'),
+                    body: bodyText,
+                    sound: null,
+                    allowDelay: false,
+                    allowSkip: false,
+                    allowTake: false,
+                });
+
+                this._scheduleUpdateState(updateTimeoutSeconds);
+            } else if (this._previousState === BreakState.IN_BREAK) {
+                const durationSecs = this._manager.getDurationForBreakType(this._manager.currentBreakType);
+                const [countdownText, countdownValue, updateTimeoutSeconds] = this._formatTimeSpan(durationSecs - breakDueAgo);
+                const bodyText = Gettext.ngettext(
+                    /* %s will be replaced with a string that describes a time interval, such as “2 minutes”, “40 seconds” or “1 hour” */
+                    'There is %s remaining in your break',
+                    'There are %s remaining in your break',
+                    countdownValue
+                ).format(countdownText);
+
+                this._ensureNotification({
+                    title: _('Break Interrupted'),
+                    body: bodyText,
+                    sound: null,
+                    allowDelay: false,
+                    allowSkip: false,
+                    allowTake: false,
+                });
+
+                this._scheduleUpdateState(updateTimeoutSeconds);
+            }
+
+            this._notification.urgency = this._urgencyForBreakType(this._manager.currentBreakType);
+            this._source.addNotification(this._notification);
+            break;
+        }
+
+        default:
+            console.assert(false, `Unknown BreakManager state: ${this._manager.state}`);
+            break;
+        }
+    }
+});
+
+const BreakNotification = GObject.registerClass({
+    Properties: {
+        'allow-delay': GObject.ParamSpec.boolean(
+            'allow-delay', null, null,
+            GObject.ParamFlags.READWRITE,
+            false),
+        'allow-skip': GObject.ParamSpec.boolean(
+            'allow-skip', null, null,
+            GObject.ParamFlags.READWRITE,
+            false),
+        'allow-take': GObject.ParamSpec.boolean(
+            'allow-take', null, null,
+            GObject.ParamFlags.READWRITE,
+            false),
+    },
+}, class BreakNotification extends MessageTray.Notification {
+    constructor(source, manager) {
+        super({
+            source,
+            resident: true,
+        });
+
+        this._manager = manager;
+        this.connect('destroy', this._onDestroy.bind(this));
+
+        this._delayAction = null;
+        this._skipAction = null;
+        this._takeAction = null;
+    }
+
+    _onDestroy(_notification, destroyedReason) {
+        // If it was destroyed by the user (by pressing the close button), skip
+        // the current break.
+        if (destroyedReason === MessageTray.NotificationDestroyedReason.DISMISSED)
+            this._manager.skipBreak();
+
+        this._manager = null;
+    }
+
+    get allowDelay() {
+        return this._delayAction !== null;
+    }
+
+    set allowDelay(allowDelay) {
+        if (allowDelay === this.allowDelay)
+            return;
+
+        if (allowDelay) {
+            this._delayAction = this.addAction(_('Delay'), this._onDelayAction.bind(this));
+        } else {
+            this.removeAction(this._delayAction);
+            this._delayAction = null;
+        }
+    }
+
+    _onDelayAction() {
+        this._manager.delayBreak();
+        this.destroy(MessageTray.NotificationDestroyedReason.SOURCE_CLOSED);
+    }
+
+    get allowSkip() {
+        return this._skipAction !== null;
+    }
+
+    set allowSkip(allowSkip) {
+        if (allowSkip === this.allowSkip)
+            return;
+
+        if (allowSkip) {
+            this._skipAction = this.addAction(_('Skip'), this._onSkipAction.bind(this));
+        } else {
+            this.removeAction(this._skipAction);
+            this._skipAction = null;
+        }
+    }
+
+    _onSkipAction() {
+        this._manager.skipBreak();
+        this.destroy(MessageTray.NotificationDestroyedReason.SOURCE_CLOSED);
+    }
+
+    get allowTake() {
+        return this._takeAction !== null;
+    }
+
+    set allowTake(allowTake) {
+        if (allowTake === this.allowTake)
+            return;
+
+        if (allowTake) {
+            this._takeAction = this.addAction(_('Take'), this._onTakeAction.bind(this));
+        } else {
+            this.removeAction(this._takeAction);
+            this._takeAction = null;
+        }
+    }
+
+    _onTakeAction() {
+        this._manager.takeBreak();
+        this.destroy(MessageTray.NotificationDestroyedReason.SOURCE_CLOSED);
+    }
+});
+
+const OsdBreakCountdownLabel = GObject.registerClass(
+class OsdBreakCountdownLabel extends St.Widget {
+    constructor(manager) {
+        super({x_expand: true, y_expand: true});
+
+        this._manager = manager;
+        this._timerId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 1, () => {
+            return this._updateState() ? GLib.SOURCE_CONTINUE : GLib.SOURCE_REMOVE;
+        });
+
+        this._box = new St.BoxLayout({
+            vertical: true,
+        });
+        this.add_child(this._box);
+
+        this._label = new St.Label({
+            style_class: 'osd-break-countdown-label',
+            text: '',
+        });
+        this._box.add_child(this._label);
+
+        Main.uiGroup.add_child(this);
+        Main.uiGroup.set_child_above_sibling(this, null);
+        this._updateState();
+        this._position();
+
+        Meta.disable_unredirect_for_display(global.display);
+        this.connect('destroy', () => {
+            if (this._timerId !== 0)
+                GLib.source_remove(this._timerId);
+            this._timerId = 0;
+
+            Meta.enable_unredirect_for_display(global.display);
+        });
+    }
+
+    _updateState() {
+        const currentTime = this._manager.getCurrentTime();
+        const nextDueTime = this._manager.getNextBreakDueTime(currentTime);
+        const breakDueInSecs = nextDueTime - currentTime;
+        console.debug(`OsdBreakCountdownLabel: breakDueInSecs ${breakDueInSecs} nextDueTime ${nextDueTime} currentTime ${currentTime}`);
+
+        if (breakDueInSecs < 1) {
+            this.destroy();
+            return false;
+        }
+
+        this._label.text = Gettext.ngettext(
+            // Translators: This is a notification to warn the user that a
+            // screen time break will start shortly.
+            'Break in %d second',
+            'Break in %d seconds',
+            breakDueInSecs
+        ).format(breakDueInSecs);
+
+        return true;
+    }
+
+    _position() {
+        let workArea = Main.layoutManager.getWorkAreaForMonitor(Main.layoutManager.primaryIndex);
+
+        if (Clutter.get_default_text_direction() === Clutter.TextDirection.RTL)
+            this._box.x = workArea.x + (workArea.width - this._box.width);
+        else
+            this._box.x = workArea.x;
+
+        this._box.y = workArea.y;
+    }
+});
index 73980edb88ab748400f6d88d993c5c31a011f0b2..67cc0bbb2638d66af908d9f062e4c71651e72374 100644 (file)
@@ -8,6 +8,7 @@ import St from 'gi://St';
 
 import * as AccessDialog from './accessDialog.js';
 import * as AudioDeviceSelection from './audioDeviceSelection.js';
+import * as BreakManager from '../misc/breakManager.js';
 import * as Config from '../misc/config.js';
 import * as Components from './components.js';
 import * as CtrlAltTab from './ctrlAltTab.js';
@@ -89,6 +90,9 @@ export let inputMethod = null;
 export let introspectService = null;
 export let locatePointer = null;
 export let endSessionDialog = null;
+export let breakManager = null;
+export let screenTimeDBus = null;
+export let breakManagerDispatcher = null;
 
 let _startDate;
 let _defaultCssStylesheet = null;
@@ -244,6 +248,11 @@ async function _initializeUI() {
 
     introspectService = new Introspect.IntrospectService();
 
+    // Set up the global default break reminder manager and its D-Bus interface
+    breakManager = new BreakManager.BreakManager();
+    screenTimeDBus = new ShellDBus.ScreenTimeDBus(breakManager);
+    breakManagerDispatcher = new BreakManager.BreakDispatcher(breakManager);
+
     layoutManager.init();
     overview.init();
 
index ba49771e97eb39f26f9c9069ca31147bb2e0a5e1..e5a8b732496742021ffc661e848a3919ddb22b4e 100644 (file)
@@ -15,6 +15,7 @@ import {ControlsState} from './overviewControls.js';
 
 const GnomeShellIface = loadInterfaceXML('org.gnome.Shell');
 const ScreenSaverIface = loadInterfaceXML('org.gnome.ScreenSaver');
+const ScreenTimeIface = loadInterfaceXML('org.gnome.Shell.ScreenTime');
 
 export class GnomeShell {
     constructor() {
@@ -542,3 +543,30 @@ export class ScreenSaverDBus {
             return 0;
     }
 }
+
+export class ScreenTimeDBus {
+    constructor(breakManager) {
+        this._manager = breakManager;
+
+        this._manager.connect('notify::state', this._onNotify.bind(this));
+        this._manager.connect('notify::last-break-end-time', this._onNotify.bind(this));
+
+        this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(ScreenTimeIface, this);
+        this._dbusImpl.export(Gio.DBus.session, '/org/gnome/Shell/ScreenTime');
+    }
+
+    _onNotify() {
+        // We always want to notify the two properties together, as clients need
+        // both values to be useful. GJS will combine the two emissions for us.
+        this._dbusImpl.emit_property_changed('State', new GLib.Variant('u', this.State));
+        this._dbusImpl.emit_property_changed('LastBreakEndTime', new GLib.Variant('t', this.LastBreakEndTime));
+    }
+
+    get State() {
+        return this._manager.state;
+    }
+
+    get LastBreakEndTime() {
+        return this._manager.lastBreakEndTime;
+    }
+}
index d623775ef03429c1a0b5b36d40c7f627a770eaab..dd6495723b88e50f6ddb512b7f2c02c21afa8272 100644 (file)
@@ -27,7 +27,7 @@ gjs_req = '>= 1.73.1'
 gtk_req = '>= 4.0'
 mutter_req = '>= 47.0'
 polkit_req = '>= 0.100'
-schemas_req = '>= 47.alpha'
+schemas_req = '>= 47.beta'
 systemd_req = '>= 246'
 ibus_req = '>= 1.5.19'
 gnome_desktop_req = '>= 40'
index f4853514b8510e1848073abfe46822c55ddbac68..25abaeb608bc64ef65c4a48f210a0569c818579f 100644 (file)
@@ -12,6 +12,7 @@ js/dbusServices/extensions/ui/extension-error-page.ui
 js/gdm/authPrompt.js
 js/gdm/loginDialog.js
 js/gdm/util.js
+js/misc/breakManager.js
 js/misc/systemActions.js
 js/misc/util.js
 js/misc/dateUtils.js
index a8f49a7ab5ff02fde8e6faedcb73b7681df05bb3..c7faa1129f492de346f8a832b893d671f279084f 100644 (file)
@@ -20,6 +20,7 @@ unit_testenv.append('GI_TYPELIB_PATH', shell_typelib_path, separator: ':')
 unit_testenv.append('GI_TYPELIB_PATH', st_typelib_path, separator: ':')
 
 unit_tests = [
+    'breakManager',
     'extensionUtils',
     'highlighter',
     'injectionManager',
diff --git a/tests/unit/breakManager.js b/tests/unit/breakManager.js
new file mode 100644 (file)
index 0000000..e744776
--- /dev/null
@@ -0,0 +1,513 @@
+// Copyright 2024 GNOME Foundation, Inc.
+//
+// This is a GNOME Shell component to support break reminders and screen time
+// statistics.
+//
+// Licensed under the GNU General Public License Version 2
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License
+// as published by the Free Software Foundation; either version 2
+// of the License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program; if not, write to the Free Software
+// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+//
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+import 'resource:///org/gnome/shell/ui/environment.js';
+import GLib from 'gi://GLib';
+
+import * as BreakManager from 'resource:///org/gnome/shell/misc/breakManager.js';
+
+// Convenience alias
+const {BreakState} = BreakManager;
+
+// A harness for testing the BreakManager class. It simulates the passage of
+// time, maintaining an internal ordered queue of events, and providing three
+// groups of mock functions which the BreakManager uses to interact with it:
+// a mock version of the IdleMonitor, mock versions of GLib’s clock and timeout
+// functions, and a mock version of Gio.Settings.
+//
+// The internal ordered queue of events is sorted by time (in seconds since an
+// arbitrary epoch; the tests arbitrarily start from 100s to avoid potential
+// issues around time zero). On each _tick(), the next event is shifted off the
+// head of the queue and processed. An event might be a simulated idle watch
+// (mocking the user being idle), a simulated active watch, a scheduled timeout,
+// or an assertion function for actually running test assertions.
+//
+// The simulated clock jumps from the scheduled time of one event to the
+// scheduled time of the next. This way, we can simulate half an hour of active
+// time (waiting for the next rest break to be due) on the computer instantly.
+class TestHarness {
+    constructor(settings) {
+        this._currentTimeSecs = 100;
+        this._nextSourceId = 1;
+        this._events = [];
+        this._idleWatch = null;
+        this._activeWatch = null;
+        this._settings = settings;
+    }
+
+    _allocateSourceId() {
+        const sourceId = this._nextSourceId;
+        this._nextSourceId++;
+        return sourceId;
+    }
+
+    _removeEventBySourceId(sourceId) {
+        const idx = this._events.findIndex(a => {
+            return a.sourceId === sourceId;
+        });
+        console.assert(idx !== -1);
+        this._events.splice(idx, 1);
+    }
+
+    _insertEvent(event) {
+        this._events.push(event);
+        this._events.sort((a, b) => {
+            return a.time - b.time;
+        });
+        return event;
+    }
+
+    // Add a timeout event to the event queue. It will be scheduled at the
+    // current simulated time plus `intervalSecs`. `callback` will be invoked
+    // when the event is processed.
+    addTimeoutEvent(intervalSecs, callback) {
+        return this._insertEvent({
+            type: 'timeout',
+            time: this._currentTimeSecs + intervalSecs,
+            callback,
+            sourceId: this._allocateSourceId(),
+            intervalSecs,
+        });
+    }
+
+    // Add an idle watch event to the event queue. This simulates the user
+    // becoming idle (no keyboard or mouse input) at time `timeSecs`.
+    addIdleEvent(timeSecs) {
+        return this._insertEvent({
+            type: 'idle',
+            time: timeSecs,
+        });
+    }
+
+    // Add an active watch event to the event queue. This simulates the user
+    // becoming active (using the keyboard or mouse after a period of
+    // inactivity) at time `timeSecs`.
+    addActiveEvent(timeSecs) {
+        return this._insertEvent({
+            type: 'active',
+            time: timeSecs,
+        });
+    }
+
+    // Add a delay action invocation to the event queue. This simulates the user
+    // invoking the ‘delay’ action (typically via a notification) at time
+    // `timeSecs`.
+    addDelayAction(timeSecs, breakManager) {
+        return this._insertEvent({
+            type: 'action',
+            time: timeSecs,
+            callback: () => {
+                breakManager.delayBreak();
+            },
+        });
+    }
+
+    // Add a skip action invocation to the event queue. This simulates the user
+    // invoking the ‘skip’ action (typically via a notification) at time
+    // `timeSecs`.
+    addSkipAction(timeSecs, breakManager) {
+        return this._insertEvent({
+            type: 'action',
+            time: timeSecs,
+            callback: () => {
+                breakManager.skipBreak();
+            },
+        });
+    }
+
+    // Add a take action invocation to the event queue. This simulates the user
+    // invoking the ‘take’ action (typically via a notification) at time
+    // `timeSecs`.
+    addTakeAction(timeSecs, breakManager) {
+        return this._insertEvent({
+            type: 'action',
+            time: timeSecs,
+            callback: () => {
+                breakManager.takeBreak();
+            },
+        });
+    }
+
+    // Add an assertion event to the event queue. This is a callback which is
+    // invoked when the simulated clock reaches `timeSecs`. The callback can
+    // contain whatever test assertions you like.
+    addAssertionEvent(timeSecs, callback) {
+        return this._insertEvent({
+            type: 'assertion',
+            time: timeSecs,
+            callback,
+        });
+    }
+
+    // Add a state assertion event to the event queue. This is a specialised
+    // form of `addAssertionEvent()` which asserts that the `BreakManager.state`
+    // equals `state` at time `timeSecs`.
+    expectState(timeSecs, breakManager, expectedState) {
+        return this.addAssertionEvent(timeSecs, () => {
+            expect(BreakManager.breakStateToString(breakManager.state))
+                .withContext(`${timeSecs}s state`)
+                .toEqual(BreakManager.breakStateToString(expectedState));
+        });
+    }
+
+    // Add a state assertion event to the event queue. This is a specialised
+    // form of `addAssertionEvent()` which asserts that the given `BreakManager`
+    // properties equal the expected values at time `timeSecs`.
+    expectProperties(timeSecs, breakManager, expectedProperties) {
+        return this.addAssertionEvent(timeSecs, () => {
+            for (const [name, expectedValue] of Object.entries(expectedProperties)) {
+                expect(breakManager[name])
+                    .withContext(`${timeSecs}s ${name}`)
+                    .toEqual(expectedValue);
+            }
+        });
+    }
+
+    _popEvent() {
+        return this._events.shift();
+    }
+
+    // Get a mock clock object for use in the `BreakManager` under test.
+    // This provides a basic implementation of GLib’s clock and timeout
+    // functions which use the simulated clock and event queue.
+    get mockClock() {
+        return {
+            getRealTimeSecs: () => {
+                return this._currentTimeSecs;
+            },
+            timeoutAddSeconds: (priority, intervalSecs, callback) => {
+                return this.addTimeoutEvent(intervalSecs, callback).sourceId;
+            },
+            sourceRemove: sourceId => {
+                this._removeEventBySourceId(sourceId);
+            },
+        };
+    }
+
+    // Get a mock idle monitor object for use in the `BreakManager` under test.
+    // This provides a basic implementation of the `IdleMonitor` which uses the
+    // simulated clock and event queue.
+    get mockIdleMonitor() {
+        return {
+            add_idle_watch: (waitMsec, callback) => {
+                console.assert(this._idleWatch === null);
+                this._idleWatch = {
+                    waitMsec,
+                    callback,
+                };
+                return 1;
+            },
+
+            add_user_active_watch: callback => {
+                console.assert(this._activeWatch === null);
+                this._activeWatch = callback;
+                return 2;
+            },
+
+            remove_watch: id => {
+                console.assert(id === 1 || id === 2);
+                if (id === 1)
+                    this._idleWatch = null;
+                else if (id === 2)
+                    this._activeWatch = null;
+            },
+        };
+    }
+
+    // Get a mock settings factory for use in the `BreakManager` under test.
+    // This is an object providing a couple of constructors for `Gio.Settings`
+    // objects. Each constructor returns a basic implementation of
+    // `Gio.Settings` which uses the settings dictionary passed to `TestHarness`
+    // in its constructor.
+    // This necessarily has an extra layer of indirection because there are
+    // multiple ways to construct a `Gio.Settings`.
+    get mockSettingsFactory() {
+        return {
+            new: schemaId => {
+                return {
+                    connect: (unusedSignalName, unusedCallback) => {
+                        /* no-op for mock purposes */
+                        return 1;
+                    },
+                    get_boolean: key => {
+                        return this._settings[schemaId][key];
+                    },
+                    get_strv: key => {
+                        return this._settings[schemaId][key];
+                    },
+                    get_uint: key => {
+                        return this._settings[schemaId][key];
+                    },
+                };
+            },
+
+            newWithPath: (schemaId, unusedPath) => {
+                return {
+                    connect: (unusedSignalName, unusedCallback) => {
+                        /* no-op for mock purposes */
+                        return 1;
+                    },
+                    get_boolean: key => {
+                        return this._settings[schemaId][key];
+                    },
+                    get_strv: key => {
+                        return this._settings[schemaId][key];
+                    },
+                    get_uint: key => {
+                        return this._settings[schemaId][key];
+                    },
+                };
+            },
+        };
+    }
+
+    _tick() {
+        const event = this._popEvent();
+        if (!event)
+            return false;
+        this._currentTimeSecs = event.time;
+
+        switch (event.type) {
+        case 'timeout':
+            if (event.callback()) {
+                event.time += event.intervalSecs;
+                this._insertEvent(event);
+            }
+            break;
+        case 'idle':
+            if (this._idleWatch)
+                this._idleWatch.callback();
+            break;
+        case 'active':
+            if (this._activeWatch) {
+                this._activeWatch();
+                this._activeWatch = null;  // one-shot
+            }
+            break;
+        case 'action':
+            event.callback();
+            break;
+        case 'assertion':
+            event.callback();
+            break;
+        default:
+            console.assert(false, 'not reached');
+        }
+
+        return true;
+    }
+
+    // Run the test in a loop, blocking until all events are processed or an
+    // exception is raised.
+    run() {
+        const loop = new GLib.MainLoop(null, false);
+        let innerException = null;
+
+        GLib.idle_add(GLib.PRIORITY_DEFAULT, () => {
+            try {
+                if (this._tick())
+                    return GLib.SOURCE_CONTINUE;
+                loop.quit();
+                return GLib.SOURCE_REMOVE;
+            } catch (e) {
+                // Quit the main loop then re-raise the exception
+                loop.quit();
+                innerException = e;
+                return GLib.SOURCE_REMOVE;
+            }
+        });
+
+        loop.run();
+
+        // Did we exit with an exception?
+        if (innerException)
+            throw innerException;
+    }
+}
+
+describe('Break manager', () => {
+    it('can be disabled via GSettings', () => {
+        const harness = new TestHarness({
+            'org.gnome.desktop.break-reminders': {
+                'selected-breaks': [],
+            },
+        });
+        const breakManager = new BreakManager.BreakManager(harness.mockClock, harness.mockIdleMonitor, harness.mockSettingsFactory);
+
+        harness.addActiveEvent(101);
+        harness.expectState(102, breakManager, BreakState.DISABLED);
+        harness.addIdleEvent(130);
+        harness.expectState(135, breakManager, BreakState.DISABLED);
+
+        harness.run();
+    });
+
+    // A simple test which simulates the user being active briefly, taking a short
+    // break before one is due, and then being active again until their next break
+    // is overdue.
+    it('tracks a single break type', () => {
+        const harness = new TestHarness({
+            'org.gnome.desktop.break-reminders': {
+                'selected-breaks': ['movement'],
+            },
+            'org.gnome.desktop.break-reminders.movement': {
+                'duration-seconds': 300,  /* 5 minutes */
+                'interval-seconds': 1800,  /* 30 minutes */
+                'delay-seconds': 300,  /* 5 minutes */
+                'notify': true,
+                'play-sound': false,
+                'fade-screen': false,
+            },
+        });
+        const breakManager = new BreakManager.BreakManager(harness.mockClock, harness.mockIdleMonitor, harness.mockSettingsFactory);
+
+        harness.addActiveEvent(101);
+        harness.expectState(102, breakManager, BreakState.ACTIVE);
+        harness.addIdleEvent(130);
+        harness.expectState(135, breakManager, BreakState.IDLE);
+        harness.addActiveEvent(200);  // cut the break short before its duration
+        harness.expectState(201, breakManager, BreakState.ACTIVE);
+        harness.expectProperties(2001, breakManager, {  // break is due after 30 mins
+            'state': BreakState.BREAK_DUE,
+            'currentBreakType': 'movement',
+            'currentBreakStartTime': 0,
+            'lastBreakEndTime': 100,
+        });
+        harness.addIdleEvent(2005);
+        harness.expectProperties(2006, breakManager, {
+            'state': BreakState.IN_BREAK,
+            'currentBreakType': 'movement',
+            'currentBreakStartTime': 1900,
+            'lastBreakEndTime': 0,
+        });
+        harness.expectState(2195, breakManager, BreakState.IN_BREAK);  // near the end of the break
+        harness.expectState(2210, breakManager, BreakState.IDLE);  // just after the end of the break
+        harness.addActiveEvent(2320);
+        harness.expectProperties(2321, breakManager, {
+            'state': BreakState.ACTIVE,
+            'currentBreakType': null,
+            'currentBreakStartTime': 0,
+            'lastBreakEndTime': 2320,
+        });
+        harness.addIdleEvent(4100);  // start the next break a little early
+        harness.expectState(4101, breakManager, BreakState.IDLE);
+        harness.expectState(4121, breakManager, BreakState.IN_BREAK);
+        harness.addActiveEvent(4420);
+        harness.expectProperties(4421, breakManager, {
+            'state': BreakState.ACTIVE,
+            'currentBreakType': null,
+            'currentBreakStartTime': 0,
+            'lastBreakEndTime': 4420,
+        });
+
+        harness.run();
+    });
+
+    // Test requesting to delay a break.
+    it('supports delaying a break', () => {
+        const harness = new TestHarness({
+            'org.gnome.desktop.break-reminders': {
+                'selected-breaks': ['movement'],
+            },
+            'org.gnome.desktop.break-reminders.movement': {
+                'duration-seconds': 300,  /* 5 minutes */
+                'interval-seconds': 1800,  /* 30 minutes */
+                'delay-seconds': 300,  /* 5 minutes */
+                'notify': true,
+                'play-sound': false,
+                'fade-screen': false,
+            },
+        });
+        const breakManager = new BreakManager.BreakManager(harness.mockClock, harness.mockIdleMonitor, harness.mockSettingsFactory);
+
+        harness.addActiveEvent(101);
+        harness.expectState(102, breakManager, BreakState.ACTIVE);
+        harness.expectProperties(1901, breakManager, {  // break is due after 30 mins
+            'state': BreakState.BREAK_DUE,
+            'currentBreakType': 'movement',
+            'currentBreakStartTime': 0,
+            'lastBreakEndTime': 100,
+        });
+        harness.addDelayAction(1902, breakManager);
+        harness.expectProperties(1903, breakManager, {  // break is delayed by 5 mins
+            'state': BreakState.ACTIVE,
+            'currentBreakType': null,
+            'currentBreakStartTime': 0,
+            'lastBreakEndTime': 400,
+        });
+        harness.expectProperties(2201, breakManager, {  // break is due after another 5 mins
+            'state': BreakState.BREAK_DUE,
+            'currentBreakType': 'movement',
+            'currentBreakStartTime': 0,
+            'lastBreakEndTime': 400,
+        });
+        harness.addIdleEvent(2202);
+        harness.expectState(2203, breakManager, BreakState.IN_BREAK);
+
+        harness.run();
+    });
+
+    // Test requesting to skip a break.
+    it('supports skipping a break', () => {
+        const harness = new TestHarness({
+            'org.gnome.desktop.break-reminders': {
+                'selected-breaks': ['movement'],
+            },
+            'org.gnome.desktop.break-reminders.movement': {
+                'duration-seconds': 300,  /* 5 minutes */
+                'interval-seconds': 1800,  /* 30 minutes */
+                'delay-seconds': 300,  /* 5 minutes */
+                'notify': true,
+                'play-sound': false,
+                'fade-screen': false,
+            },
+        });
+        const breakManager = new BreakManager.BreakManager(harness.mockClock, harness.mockIdleMonitor, harness.mockSettingsFactory);
+
+        harness.addActiveEvent(101);
+        harness.expectState(102, breakManager, BreakState.ACTIVE);
+        harness.expectProperties(1901, breakManager, {  // break is due after 30 mins
+            'state': BreakState.BREAK_DUE,
+            'currentBreakType': 'movement',
+            'currentBreakStartTime': 0,
+            'lastBreakEndTime': 100,
+        });
+        harness.addSkipAction(1902, breakManager);
+        harness.expectProperties(1903, breakManager, {  // break is skipped for 30 mins
+            'state': BreakState.ACTIVE,
+            'currentBreakType': null,
+            'currentBreakStartTime': 0,
+            'lastBreakEndTime': 1902,
+        });
+        harness.expectProperties(3703, breakManager, {  // break is due after another 30 mins
+            'state': BreakState.BREAK_DUE,
+            'currentBreakType': 'movement',
+            'currentBreakStartTime': 0,
+            'lastBreakEndTime': 1902,
+        });
+        harness.addIdleEvent(3704);
+        harness.expectState(3704, breakManager, BreakState.IN_BREAK);
+
+        harness.run();
+    });
+});